/**
 * ******************************************************************************
 * 	Copyright 2011 Gerry Weirich
 *
 *  Licensed under the Apache License, Version 2.0 (the "License");
 *  you may not use this file except in compliance with the License.
 *  You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 *  Unless required by applicable law or agreed to in writing, software
 *  distributed under the License is distributed on an "AS IS" BASIS,
 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 *  See the License for the specific language governing permissions and
 *  limitations under the License.
 *
 * 	$Id$
 * ******************************************************************************
 */

package ch.rgw.cybert;

import ch.rgw.cybert.persist._
import org.ektorp.DocumentNotFoundException
import org.ektorp.UpdateConflictException
import scala.collection.JavaConversions._

/**
 * Think of it as a sort of (cyb)ernetic exp(ert). Or provide a better name :-)
 * @author gerry
 *
 */
object Cybert {
  val debug = false;
  val log = java.util.logging.Logger.getLogger("Node")
  private def connect(server: String, database: String) = Connector.createCouchDbConnector(server, database);
  private def getConnector = Connector.getConnector;

  private val hypothesis = new Hypothesis;
  private var lastQuestion: Option[Quant] = None
  private[cybert] var nodersc = new NodeRsc();
  private[cybert] var nodes = List[Node]();
  private var repository: NodeRepository = null
  private var usedQuestionDump = List[Int]();

  def getHistory() = hypothesis.dump;

  /**
   * Flush this contents to the cloud. If an update conflict happens, merge changes first
   */
  def flush: Unit = {
    require(repository != null)
    try {
      getConnector.update(nodersc);
    } catch {
      case uex: UpdateConflictException => {
        val other = getConnector.get(classOf[NodeRsc], NodeRsc.jacksonID);
        nodersc.merge(other);
        flush;
      }
      case x: Exception => throw x
      case unknown => throw new Exception("Highly illogical Cybert-Exception") // ouch!! This should really not happen

    }
    repository.putNodes(nodes);
  }

  /**
   * load contents from the cloud. If no database exists at the given location, create it.
   * @param url: IP or name (without protocol). and optional ',username,password' attached.
   * @param database: Database to access at the given url.
   * @throws Exception if can't connect to couch
   */
  def setup(url: String, database: String) {
    require(database != null)
    require(database.length > 0)
    connect(url, database)
    repository = new NodeRepository(getConnector)
    val wrappers = List[NodeWrapper]() ++ repository.getAll()
    wrappers.foreach(qw => addNode(qw.createNode))
    try {

      nodersc = getConnector.get(classOf[NodeRsc], NodeRsc.jacksonID)
    } catch {
      case dex: DocumentNotFoundException => {
        nodersc = new NodeRsc();
        getConnector.create(nodersc)
      }
    }
  }

  /**
   * Add a new Question to the actual hypothesis. Accessor for hypothesis.addQuestion(...)
   */
  def addQuestion(question: String, initialValue: Int, creator: String, phase: Int, ppv: Float, npv: Float) = hypothesis.addQuestion(question, initialValue, creator, phase, ppv, npv)

  /**
   * Add a new hypothesis to the system
   * @param thesis - title of the hypothesis
   * @param discriminatingQuestion - A question that might help zu lead to this thesis
   * @param discriminating value - A Value between 1 and 9 inclusive to denote the "typical" answer on the discriminating question to
   * lead to this theses. Note: Values 1 and 9 are preferrable over values between!
   * @param creator: UUID of the system this thesis comes from
   * @param phase: Phase of the questionnaire this question should belong to
   * @param ppv positive predictive value
   * @param npv negative predictive value
   */
  def addProposal(thesis: String, discriminatingQuestion: String, discriminatingValue: Int, creator: String, phase: Int, ppv: Float, npv: Float) {
    require(thesis != null && thesis.length > 0)
    val knol = findNodeWith(thesis).getOrElse(addNode(new Node(thesis, creator)));
    val idx = nodersc.findOrAddQuestion(discriminatingQuestion);
    val quant = new Quant(idx, creator, phase);
    quant.setPpv(ppv)
    quant.setNpv(npv)
    quant.rebalance(discriminatingValue, false);
    knol.addQuant(quant);
  }

  /**
   * return an Option with a Node matching the given thesis.
   */
  def findNodeWith(thesis: String): Option[Node] = {
    require(thesis != null)
    nodes.foreach(k => {
      if (k.getLabel.equals(thesis)) {
        return Some(k)
      }
    });
    None;
  }

  /**
   * Reset system to initial state and find some questions to start with. Tries to find num best discriminating questions
   * @param num Number of questions to retrieve
   * @return a list containing at most num questions. Might be less if less that num questions were found in the database
   */
  def getInitialQuestions(num: Int): java.util.List[String] = {
    hypothesis.clear
    usedQuestionDump = List[Int]()
    var ret = List[String]()
    for (i <- 1 to num) {
      findBestDiscriminatingQuestion(0) match {
        case Some(q) => {
          val qid = q.getQuestionID;
          hypothesis.entryQuestions = qid :: hypothesis.entryQuestions
          ret = nodersc.getQuestion(qid).get :: ret
          usedQuestionDump = qid :: usedQuestionDump
        }
        case None => return ret;
      }
    }
    return ret;
  }

  /**
   * The user selected one of the proposals matching. The hypothesis is merged into this proposal.
   * Then the system saves state.
   * @param x: Index of the knol to merge.
   */
  private def mergeHypothesisInto(x: Int) {
    nodes(x).merge(hypothesis);
    flush;
  }

  /**
   * Apply the hypothesis to the Node with the given title. If no such knol exists, it will
   * be created. Flush the new state of the system to the persistence layer
   * @param title: Text of the Node
   * @param creator: If the Node does not exist, create one with the given UUID
   */
  def applyHypothesis(title: String, creator: String) {
    require(title != null && creator != null)
    val knol = findNodeWith(title).getOrElse(new Node(title, creator));
    val sb = new StringBuilder
    if (debug) {
      sb.append("applying hypothesis to ").append(knol.getLabel).append("\n")
        .append("Node: \n").append(knol.dump).append("\nHypothesis: ").append(hypothesis.dump)
    }
    addNode(knol);
    knol.merge(hypothesis);
    flush;
    if (debug) {
      sb.append("\n--------\nafter merge:\n").append(knol.dump).append("\n")
      log.info(sb.toString)
    }
  }
  /**
   * return a first quesions based on the filtering applied by the initial questions
   * @param answers an Array containing the answers (values between -4 and 4) to the initial questions.
   * @return a Question
   */
  def getFirstQuestion(answers: java.util.List[Int]): String = {
    val zipped = hypothesis.entryQuestions.zip(answers);
    zipped.foreach(tuple => {
      val quant = new Quant(tuple._1, "system", 0)
      quant.rebalance(tuple._2, false)
      hypothesis.addQuant(quant)
    })

    if (debug) {
      log.info("Hypothesis Dump: " + hypothesis.dump);
    }
    nodes = reorderWith(hypothesis);
    lastQuestion = findBestDiscriminatingQuestion(0);
    lastQuestion match {
      case Some(x) => {
        val quid = x.getQuestionID;
        usedQuestionDump = quid :: usedQuestionDump
        return nodersc.getQuestion(quid).get;

      }
      case None => return null;
    }

  }

  /**
   * return the question cosidered best discriminating among the best hypotheses, based on the answer to the
   * previous question
   * @param lastAnswer Answer to the previous question as Integer between -4 and 4
   * @return: The question as String or null if no question was left.
   */
  def getNextQuestion(lastAnswer: Int): String = {
    require(lastQuestion != None);
    val q = new Quant(lastQuestion.get);
    q.rebalance(lastAnswer, false)
    hypothesis.addQuant(q)
    nodes = reorderWith(hypothesis)
    lastQuestion = findBestDiscriminatingQuestion(0);
    lastQuestion match {
      case Some(q) =>
        usedQuestionDump = lastQuestion.get.getQuestionID :: usedQuestionDump
        nodersc.getQuestion(lastQuestion.get.getQuestionID).get;
      case None => null
    }
  }

  /**
   * find the best dirscriminating question. To achieve this, add Quants of the most probable hypotheses.
   * Quesions from the same Cluster than the previous question are preferred.
   * If Questions are from the same cluster, the question with the largest range of answers (min,max-Value)
   * is considered best discriminating. Start always with questions from the lower phases
   */
  private def findBestDiscriminatingQuestion(phase: Int): Option[Quant] = {
    val map = scala.collection.mutable.HashMap[Quant, (Float, Float)]();
    var phase_max: Int = 0
    nodes.foreach(knol => phase_max = math.max(addQuestionsFromNode(knol, map, phase), phase_max))

    if (map.isEmpty) {
      // If no Quants were found, go to higher phases. If no higher phases, return "None"
      if (phase >= phase_max) {
        None
      } else {
        findBestDiscriminatingQuestion(phase + 1)
      }

    } else {
      // If quants were found, select the one with the maximum range between min and max value.
      if (debug) {
        val sb = new StringBuilder();
        map.foreach(tupel => sb.append(nodersc.getQuestion(tupel._1.getQuestionID) + " " + tupel._2._1.toString + "," + tupel._2._2.toString).append("\n"))
        log.info("Question listed (unordered):" + sb.toString());
      }

      // order Quants by cluster first, then by span 
      val head = map.reduceLeft((a, b) => {
        val env = lastQuestion match {
          case Some(quant) => {
            a._1.compareEnvironment(quant) - b._1.compareEnvironment(quant)
          }
          case None => 0
        }
        if (env < 0)
          a
        else if (env > 0)
          b
        else {
          val dist_a = a._2._2 - a._2._1;
          val dist_b = b._2._2 - b._2._1;
          if (dist_a < dist_b) b else a
        }
      })

      if (debug) {
        log.info("Best Discriminating Question was: " + nodersc.getQuestion(head._1.getQuestionID));
      }
      Some(head._1)
    }
  }

  /**
   * Add the Quants from a Node to the given Map. The Map takes the quants as keys and a tuple of min/max
   * answer range as values. Note: The Method modifies the map
   * @param knol The knol to take Quants from
   * @param map The map where the found Quants are stored
   * @param phase consider only quants of the given phase
   * @return the highest phase found in Quants of this Node
   *
   */
  private def addQuestionsFromNode(knol: Node, map: scala.collection.mutable.Map[Quant, (Float, Float)], phase: Int): Int = {
    def debugOutput(q: Quant) = {
      val sb = new StringBuilder();
      sb.append("Adding Questions from phase ").append(phase).append("): Considering: ")
        .append(q.getQuestionID).append(":=>")
        .append(nodersc.getQuestion(q.getQuestionID)).append("(").append(q.getValue.toString).append(")")
      log.info(sb.toString());

    }
    var phase_max = 0;
    knol.getQuants.values.foreach(quant => {
      val qPhase = quant.getPhase
      if (qPhase > phase_max) {
        phase_max = qPhase
      }
      if (qPhase == phase) {
        if (debug) {
          debugOutput(quant)
        }
        // do not ask the same question twice
        if (!usedQuestionDump.contains(quant.getQuestionID)) {
          // if this question is already marked, only add this value
          if (map.contains(quant)) {
            val exist = map.get(quant).get
            val thisValue = quant.getValue;
            map.put(quant, (math.min(exist._1, thisValue), math.max(exist._2, thisValue)))
          } else {
            map.put(quant, (0.0f, 0.0f));
          }
        }
      }
    })
    phase_max
  }

  /**
   * Add a Node to this cybert's list of nodes. Looks first if it's already here and does mothing if so.
   * @param knol Node to add
   * @returns the newly added (or the already existing) Node
   */
  def addNode(knol: Node): Node = {
    val exists = findNodeWith(knol.getLabel);
    exists match {
      case None => nodes = knol :: nodes; knol
      case Some(x) => x //do nothing
    }

  }

  /**
   * Reorder the list of nodes so that nodes more alike to the reference come first.
   * Computes the "alikeness" of each Node to the reference and
   * takes the result as criterion for ordering. If to nodes have an equal alikeness to the reference,
   * the more frequently used Node wins.
   */
  private def reorderWith(reference: Node): List[Node] = {
    val sb = new StringBuilder
    if (debug) {
      sb.append("before reorder: \n");
      nodes.foreach(knol => sb.append(knol.getLabel).append(","))
      sb.append("\n")
    }
    val ret = nodes.sortWith((k1, k2) => {
      val d1 = k1.computeTotalDistanceTo(reference)
      val d2 = k2.computeTotalDistanceTo(reference)
      if (debug) {
        sb.append(k1.getLabel).append(": ").append(d1).append(" <-> ").append(k2.getLabel).append(": ").append(d2).append("\n");
      }
      if (d1 == d2) k1.getFrequency > k2.getFrequency else d1 < d2
    })
    if (debug) {
      sb.append("\nafter reorder: \n")
      ret.foreach(knol => sb.append(knol.getLabel).append(","))
      sb.append("\n")
      log.info(sb.toString);
    }
    ret;
  }

  /**
   * get the 5 most probable hypotheses as java List
   */
  def getProposals: java.util.List[String] = nodes.splitAt(5)._1.map(k => k.getLabel)

  /**
   * get ordered lists of hypotheses and their respective probability weights
   * @param labels: Array of Strings that will receive the labels of the most probable hypotheses.
   * At most labels.size hypotheses will be returned.
   * @param vals: Array of doubles that will receive the relative weights of these hypotheses.
   * Size must be equal to labels.size
   *
   */
  def getWeightedHypotheses(labels: Array[String], vals: Array[Double]) = {
    require(labels.size > 0)
    require(labels.size == vals.size)
    var sum = 0.0;
    val result = nodes.map(knol => {
      val distance = knol.computeTotalDistanceTo(hypothesis)
      sum = sum + distance;
      (knol.getLabel, distance)
    })
    val percent = sum / 100.0;
    for (i <- 0 until labels.size) {
      labels(i) = result(i)._1
      vals(i) = result(i)._2 / percent
    }
  }

  /**
   * log output of all the nodes of this cybert
   */
  private def logNodes(heading: String) {
    val sb = new StringBuilder();
    sb.append(heading);
    for (s <- getProposals) {
      sb.append(s).append(",");
    }
    log.info(sb.toString());
  }

  def dumpNode(name: String): String = {
    findNodeWith(name) match {
      case Some(knol) => knol.dump
      case None => ""
    }
  }

  /**
   * return the hypotheses that contain a filter String. (can be used for content assist)
   * @param flt: The String that should be part of the hypothesis
   */
  def getHypothesesFiltered(flt: String): Array[String] = {
    val matcher = ".*" + flt + ".*".toLowerCase;
    // Oerks.
    nodes.filter(knol => knol.getLabel.toLowerCase.matches(matcher)).map(_.getLabel).sortWith((a, b) => a < b).toArray
  }

  /**
   * return the subset of all questions known to the systemthat contains a filter String.
   * (Can be used for content assist)
   * @param flt: The String that must be part of the returned questions
   */
  def getQuestionsFiltered(flt: String): Array[String] = {
    val matcher = ".*" + flt.toLowerCase + ".*"
    val mapped = nodersc.questions.map(elem => elem._2).toSeq
    val filtered = mapped.filter(elem => elem != null && elem.toLowerCase.matches(matcher))
    filtered.sortWith((a, b) => a < b).toArray
  }

}